跳到主要内容

GPM 模型学习

传统多线程实现并发的缺点

线程的上下文切换

多任务系统往往需要同时执行多道作业。作业数往往大于机器的 CPU 数,然而一颗 CPU 同时只能执行一项任务。

为了让用户感觉这些任务正在同时进行,操作系统的设计者巧妙地利用了时间片轮转的方式,CPU 给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务。任务的状态保存及再加载,这段过程就叫做上下文切换。

时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能,但同时也带来了保存现场和加载现场的直接消耗。

以电商系统为例,假设有一个在线购物网站:

  • 每个用户请求(浏览商品、下订单、支付等)都创建一个线程
  • Java 中每个线程默认消耗 1MB 内存
  • 如果有 10,000 个并发用户,就需要 10GB 内存仅用于线程栈
  • 频繁的上下文切换会导致 CPU 大量时间花费在切换上,而不是实际的业务处理

注意:上下文切换带来的消耗,以及线程的利用率是核心问题,至于锁之类的问题,那是线程共享模型的问题,与多线程本身关系不大。

为了解决上面这个问题,Go 语言引入了 GMP 模型,它的 GMP 模型封装了线程的创建和调度的细节,让程序员可以用更少的资源实现更高的并发。

上面上下文切换主要是系统在做,所以协程的思路就是(有栈协程)把线程分成 "用户线程" 和 "内核线程"(真正的线程),用户线程底层绑定内核线程(多对一的关系),用户操作的都是 Go 给用户分配的 "用户线程",每次上下文切换是操作在堆上模拟的一套栈空间,实际并非系统级的上下文切换。

可以理解为 goroutine 是由官方实现的超级 "线程池"。

具体可以参考

docs/编程语言/Go/Go 并发相关/GMP 模型-和传统线程解析与对比.md

Go 的线程调度器 GPM

GPM 模型架构

G(Goroutine):表示 Goroutine,每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行。

P(Processor):表示逻辑处理器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P 才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的 G 的数量(前提:物理 CPU 核数 >= P 的数量),P 的数量由用户设置的 GOMAXPROCS 决定,但是不论 GOMAXPROCS 设置为多大,P 的数量最大为 256。

M(Machine):OS 线程抽象,代表着真正执行计算的资源,在绑定有效的 P 后,进入 schedule 循环;而 schedule 循环的机制大致是从 Global 队列、P 的 Local 队列以及 wait 队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M 的数量是不定的,由 Go Runtime 调整,为了防止创建过多 OS 线程导致系统调度不过来,目前默认最大限制为 10000 个。

注意

在 GMP 模型中,M 和 P 之间存在着关联和配对的关系,但不是一一对应的。每个 P 可以关联多个 M,但每个 M 只能属于一个 P。P 负责将 Goroutine 分配给可用的 M 进行执行,并负责在 M 阻塞时将其转移到其他可用的 M。这种分配和转移的过程是由 Go 运行时系统自动管理的,开发人员无需显式干预。

调度过程

Goroutine 调度策略

队列轮转(抢占模式)

上图展示了每个 P 维护着一个包含 G 的队列,不考虑 G 进入系统调用或 IO 操作的情况下,P 周期性的将 G 调度到 M 中执行,执行一小段时间,将上下文保存下来,然后将 G 放到队列尾部,然后从队列中重新取出一个 G 进行调度。

这里可能存在一个问题,就是如果某个 G 一直占用 CPU 进行计算(紧凑循环,里面不调用其他函数),其他 G 就会饿死,Go 1.14 版本开始引入了基于抢占的公平调度,如果某个 G 长时间占用 CPU,调度器会在其函数调用入口处将其“抢占”,切换给其他等待的 G。

具体可以参考文档

docs/编程语言/Go/Go 并发相关/GMP 模型-基于抢占的公平调度.md

系统调用处理(阻塞处理)

以文件上传服务为例,当 Goroutine 需要读取大文件时:

当 M 运行的某个 G 产生系统调用时:

  1. 系统调用开始:当 G0 进入系统调用(如文件 IO)时,M0 将释放 P,进而某个空闲的 M1 获取 P,继续执行 P 队列中剩下的 G
  2. 保证 CPU 利用率:M1 接替 M0 的工作,只要 P 不空闲,就可以保证充分利用 CPU
  3. 系统调用结束:当 G0 系统调用结束后,根据 M0 是否能获取到 P,将会对 G0 做不同处理:
    • 如果有空闲的 P,则获取一个 P,继续执行 G0
    • 如果没有空闲的 P,则将 G0 放入全局队列,等待被其他的 P 调度,然后 M0 进入缓存池睡眠

这里具体阻塞处理可以参考文档

docs/编程语言/Go/Go 并发相关/GMP 模型-怎么处理阻塞操作的.md

工作量窃取(Work Stealing)

以电商系统为例,不同类型的请求处理时间差异很大:

多个 P 中维护的 G 队列有可能是不均衡的,此时空闲的 P 会将其他 P 中的 G 偷取一部分过来,一般每次偷取一半,实现负载均衡。

这里的实现细节可以参考

docs/编程语言/Go/Go 并发相关/GMP 模型-怎么确定分配给哪个 P.md

里面详细介绍了 P 的选择和窃取的具体实现,怎么从最开始 main 启动,然后怎么工作量窃取的全流程

GOMAXPROCS 应该怎么设置?

决策流程

实际场景建议

动态调整示例

以实际的电商服务为例:

package main

import (
"fmt"
"runtime"
"time"
)

// 模拟CPU密集型任务:计算商品推荐算法
func calculateRecommendations(userID int) {
// 模拟复杂计算
sum := 0
for i := 0; i < 1000000; i++ {
sum += i * userID
}
fmt.Printf("用户 %d 的推荐计算完成,结果: %d\n", userID, sum%1000)
}

// 模拟IO密集型任务:数据库查询
func queryUserOrders(userID int) {
// 模拟数据库查询延迟
time.Sleep(100 * time.Millisecond)
fmt.Printf("用户 %d 的订单查询完成\n", userID)
}

func main() {
// 获取CPU核心数
numCPU := runtime.NumCPU()
fmt.Printf("系统CPU核心数: %d\n", numCPU)

// 场景1: CPU密集型任务,设置为CPU核心数
fmt.Println("\n=== CPU密集型任务测试 ===")
runtime.GOMAXPROCS(numCPU)
start := time.Now()
for i := 0; i < 10; i++ {
go calculateRecommendations(i + 1)
}
time.Sleep(2 * time.Second)
fmt.Printf("CPU密集型任务耗时: %v\n", time.Since(start))

// 场景2: IO密集型任务,可以设置为1来观察差异
fmt.Println("\n=== IO密集型任务测试 (GOMAXPROCS=1) ===")
runtime.GOMAXPROCS(1)
start = time.Now()
for i := 0; i < 10; i++ {
go queryUserOrders(i + 1)
}
time.Sleep(2 * time.Second)
fmt.Printf("IO密集型任务耗时 (GOMAXPROCS=1): %v\n", time.Since(start))

// 场景3: IO密集型任务,设置为CPU核心数
fmt.Println("\n=== IO密集型任务测试 (GOMAXPROCS=CPU核心数) ===")
runtime.GOMAXPROCS(numCPU)
start = time.Now()
for i := 0; i < 10; i++ {
go queryUserOrders(i + 1)
}
time.Sleep(2 * time.Second)
fmt.Printf("IO密集型任务耗时 (GOMAXPROCS=%d): %v\n", numCPU, time.Since(start))
}

监控和调优

package main

import (
"fmt"
"runtime"
"time"
)

func monitorGoroutines() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
fmt.Printf("当前Goroutine数量: %d, GOMAXPROCS: %d\n",
runtime.NumGoroutine(), runtime.GOMAXPROCS(0))
}
}
}

func heavyTask(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("任务 %d 正在执行步骤 %d\n", id, i+1)
time.Sleep(1 * time.Second)
}
}

func main() {
// 启动监控
go monitorGoroutines()

// 设置GOMAXPROCS为1,观察串行执行效果
fmt.Println("=== GOMAXPROCS = 1 ===")
runtime.GOMAXPROCS(1)

for i := 0; i < 3; i++ {
go heavyTask(i + 1)
}

time.Sleep(10 * time.Second)

// 设置GOMAXPROCS为CPU核心数,观察并行执行效果
fmt.Println("\n=== GOMAXPROCS = CPU核心数 ===")
runtime.GOMAXPROCS(runtime.NumCPU())

for i := 3; i < 6; i++ {
go heavyTask(i + 1)
}

time.Sleep(10 * time.Second)
}

总结建议

  1. 默认值:Go 语言运行时默认将 GOMAXPROCS 设置为可用的 CPU 核心数,在大多数情况下已经是合理选择

  2. CPU 密集型:可以设置为 CPU 核心数或略大于核心数(如核心数 + 1)

  3. IO 密集型:通常设置为 CPU 核心数即可,过大可能导致过多上下文切换

  4. 性能测试:在生产环境部署前,应该进行压力测试,找到最适合业务场景的 GOMAXPROCS 值

  5. 动态调整:可以使用 runtime.GOMAXPROCS() 函数在运行时动态调整,但通常不建议频繁修改